package com.googlecode.vmock;

import java.lang.reflect.AccessibleObject;
import java.lang.reflect.Constructor;
import java.util.HashMap;
import java.util.IdentityHashMap;

import com.googlecode.vmock.matchers.CompositeMatcher;
import com.googlecode.vmock.matchers.EqualsMatcher;
import com.googlecode.vmock.matchers.Matcher;

/**
 * The brain of VMock.
 * <ol>
 * <li>Remembers the current mocking stage</li>
 * <li>Remembers who the record owner is</li>
 * <li>Remembers all calls mocked in the {@link MockStage#RECORDING} stage.</li>
 * <li>Remembers the replay state for every recorded call.</li>
 * <li>Knows about placeholder objects which map to the specific matcher objects.</li>
 * </ol>
 * 
 * @author marcin.jagus
 */
class MockController {

    /**
     * One and only instance of this controller.
     */
    private static MockController INSTANCE = new MockController();

    /**
     * Current mock stage.
     */
    private MockStage mockStage;

    /**
     * Record owner. Useful only in {@link MockStage#RECORDING} stage.
     */
    private Object recordOwner;

    /**
     * The (currently) last call that was intercepted during the recording stage. May be <code>null</code> if no call was intercepted in the recording
     * stage.
     */
    private RecordedCall lastRecordedCall;

    /**
     * Replay states for every recorded call.
     */
    private HashMap<RecordedCall, ReplayState> replayStates;

    /**
     * Maps placeholder objects to specific matchers.
     */
    private IdentityHashMap<Object, Matcher> matcherPlaceholders;

    /**
     * Creates new mock controller.
     */
    private MockController() {
        mockStage = MockStage.INITIALIZED;
        recordOwner = null;
        lastRecordedCall = null;
        replayStates = new HashMap<RecordedCall, ReplayState>();
        matcherPlaceholders = new IdentityHashMap<Object, Matcher>();
    }

    /**
     * Returns the one and only instance of the mock controller.
     * 
     * @return Instance of the mock controller
     */
    public static MockController getInstance() {
        return INSTANCE;
    }

    /**
     * Changes the one and only instance of the mock controller. Used mostly by unit tests.
     * 
     * @param controller New controller
     */
    static void setInstance(MockController controller) {
        INSTANCE = controller;
    }

    /**
     * Returns <code>true</code> if this controller is in {@link MockStage#RECORDING} stage.
     * 
     * @return <code>true</code> if this controller is in {@link MockStage#RECORDING} stage
     */
    public boolean isRecording() {
        return mockStage == MockStage.RECORDING;
    }

    /**
     * Returns <code>true</code> if this controller is in {@link MockStage#REPLAYING} stage.
     * 
     * @return <code>true</code> if this controller is in {@link MockStage#REPLAYING} stage
     */
    public boolean isReplaying() {
        return mockStage == MockStage.REPLAYING;
    }

    /**
     * Starts a {@link MockStage#RECORDING} stage for a given record owner.
     * 
     * @param recordOwner Object that will have the ability to mock calls. Known as record owner.
     */
    public void record(Object recordOwner) {
        if (mockStage != MockStage.INITIALIZED && mockStage != MockStage.RECORDING_PAUSED) {
            // most likely previous replaying session failed with an unexpected exception
            // we allow the user to move gracefully from unstable state back to recording state
            // but we clear the state of mock controller in advance
            resetStates();
        }

        this.recordOwner = recordOwner;
        mockStage = MockStage.RECORDING;
    }

    /**
     * Moves controller to a {@link MockStage#RECORDING_PAUSED} stage.
     */
    public void pauseRecording() {
        if (mockStage == MockStage.RECORDING) {
            recordOwner = null;
            mockStage = MockStage.RECORDING_PAUSED;
        } else {
            throw new IllegalStateException("Cannot go to RECORDING_PAUSED from " + mockStage
                    + " state. You can only move to RECORDING_PAUSED state from RECORDING state.");
        }
    }

    /**
     * Starts {@link MockStage#REPLAYING} stage for this controller.
     */
    public void replay() {
        if (mockStage == MockStage.RECORDING || mockStage == MockStage.RECORDING_PAUSED) {
            mockStage = MockStage.REPLAYING;
        } else {
            throw new IllegalStateException("Cannot go to REPLAYING state from " + mockStage
                    + " state. You can only move to REPLAYING state from RECORDING or RECORDING_PAUSED state.");
        }
    }

    /**
     * Starts {@link MockStage#VERIFYING} stage for this controller.
     */
    public void verify() {
        if (mockStage == MockStage.REPLAYING) {
            mockStage = MockStage.VERIFYING;
            try {
                verifyCalls();
            } finally {
                resetStates();
                mockStage = MockStage.INITIALIZED;
            }
        } else {
            throw new IllegalStateException("Cannot go to VERIFYING state from " + mockStage
                    + " state. You can only move to VERYFING stage from REPLAYING state.");
        }
    }

    /**
     * Verifies if all known recorded calls where invoked expected number of times.
     */
    private void verifyCalls() {
        for (ReplayState replayState : replayStates.values()) {
            replayState.verifyCalls();
        }
    }

    /**
     * Clears "memory" of this controller. All the data about last recorded and replayed session will be lost.
     */
    private void resetStates() {
        lastRecordedCall = null;
        replayStates.clear();
        matcherPlaceholders.clear();
    }

    /**
     * Returns <code>true</code> if given call was captured during {@link MockStage#RECORDING} stage.
     * 
     * @param call a given call
     * @return <code>true</code> if given call was captured during {@link MockStage#RECORDING} stage
     */
    public boolean isRecorded(InterceptedCall call) {
        return getReplayState(call) != null;
    }

    /**
     * Returns replay state for a given call.
     * <p>
     * If there is no replay state for a given call and a given call has non-null callee, returns replay state for a call mocked without callee (i.e.
     * mocked for whole class) which still can be null.
     * 
     * @param call Given call
     * @return replay state for a given call. May be <code>null</code>.
     */
    private ReplayState getReplayState(InterceptedCall call) {
        ReplayState replayState = replayStates.get(call);

        if (replayState == null && call.getCallee() != null) {
            InterceptedCall classOverridenCall = new InterceptedCall(call.getCaller(), null, call.getCallableObject());
            replayState = replayStates.get(classOverridenCall);
        }

        return replayState;
    }

    /**
     * Returns last call that was intercepted in the {@link MockStage#RECORDING} stage.
     * 
     * @return last recorded call
     */
    public RecordedCall getLastRecordedCall() {
        return lastRecordedCall;
    }

    /**
     * Handles intercepted call with given call parameters.
     * <p>
     * Registers new recorded call if interception happened in the {@link MockStage#RECORDING} stage.
     * <p>
     * Returns mocked result if interception happened in the {@link MockStage#REPLAYING} stage and the call was previously registered in the
     * {@link MockStage#RECORDING} stage.
     * <p>
     * Returns {@link InterceptionResult#NOT_OVERRIDEN} otherwise.
     * 
     * @param call intercepted call
     * @param params parameters of intercepted call
     * @return result of interception
     * @throws Throwable when expected result of a mocked call is an exception throw
     */
    public InterceptionResult handleInterceptedCall(InterceptedCall call, Object[] params) throws Throwable {
        if (isRecording() && call.getCaller().equals(recordOwner)) {
            RecordedCall recordedCall = new RecordedCall(call, createMatchers(params, call.isVarArgs()));
            lastRecordedCall = recordedCall;

            return new InterceptionResult(null);
        } else if (isReplaying()) {
            return getExpectedResultFromReplayedCall(call, params);
        } else {
            return handleRecordAndReplayConstructors(call.getCallableObject());
        }
    }

    /**
     * Creates matchers for a given array of call parameters.
     * 
     * @param params call parameters
     * @param isVarArgs are we dealing with varargs call here?
     * @return Array of matchers for a given array of call parameters
     */
    private Matcher[] createMatchers(Object[] params, boolean isVarArgs) {
        Matcher[] matchers = new Matcher[params.length];

        for (int i = 0; i < matchers.length; i++) {
            Object param = params[i];

            if (param instanceof Matcher) {
                matchers[i] = (Matcher) param;
            } else {
                Matcher matcher = getMatcher(param);
                if (matcher != null) {
                    matchers[i] = matcher;
                } else if (isVarArgs && i == matchers.length - 1) {
                    Matcher[] varArgsMatchers = createMatchers((Object[]) param, false);
                    matchers[i] = new CompositeMatcher(varArgsMatchers);
                } else {
                    matchers[i] = new EqualsMatcher(param);
                }
            }
        }

        return matchers;
    }

    /**
     * Registers new recorded call. Moves to {@link MockStage#RECORDING_AWAITING_RESULT} stage afterwards, expecting a result from end-user.
     * 
     * @param call recorded call
     */
    public void registerRecordedCall(RecordedCall call) {
        if (!replayStates.containsKey(call)) {
            replayStates.put(call, new ReplayState());
        }

        mockStage = MockStage.RECORDING_AWAITING_RESULT;
    }

    /**
     * Registers expected result for a given call. Moves to {@link MockStage#RECORDING} stage afterwards.
     * 
     * @param call given call
     * @param expectedResult given expected result
     */
    public void registerExpectedResult(InterceptedCall call, ExpectedCallResult expectedResult) {
        if (!isRecorded(call)) {
            throw new IllegalStateException("Cannot mock a call because it was never recorded. Something is terribly wrong.");
        } else if (mockStage != MockStage.RECORDING_AWAITING_RESULT) {
            throw new IllegalStateException("An expected result was given when it was not expected.");
        }

        replayStates.get(call).addExpectedResult(expectedResult);
        mockStage = MockStage.RECORDING;
    }

    /**
     * Registers new matcher for a given placeholder object.
     * 
     * @param matcher Matcher
     * @param object Placeholder
     */
    public void registerMatcher(Matcher matcher, Object object) {
        if (!matcherPlaceholders.containsKey(object)) {
            matcherPlaceholders.put(object, matcher);
        } else {
            throw new IllegalStateException("Given placeholder " + object + " already has a registered matcher: " + matcherPlaceholders.get(object));
        }
    }

    /**
     * Returns expected result for a call intercepted in a REPLAYING stage, which was earlier registered in a RECORDING stage. Returns
     * {@link InterceptionResult#NOT_OVERRIDEN} if there is no expected result for a given call.
     * 
     * @param call Given call
     * @param params Call parameters
     * @return Expected result or {@link InterceptionResult#NOT_OVERRIDEN} object.
     * @throws Throwable When expected result is an exception
     */
    public InterceptionResult getExpectedResultFromReplayedCall(InterceptedCall call, Object[] params) throws Throwable {
        ReplayState replayState = getReplayState(call);

        if (replayState != null) {
            return replayState.replayCall(params);
        } else {
            return InterceptionResult.NOT_OVERRIDEN;
        }
    }

    /**
     * Returns matcher for a given placeholder object.
     * 
     * @param object Placeholder object
     * @return {@link Matcher} object or <code>null</code>
     */
    public Matcher getMatcher(Object object) {
        return matcherPlaceholders.get(object);
    }

    /**
     * Returns {@link InterceptionResult#INTERCEPT_RECORDING} object if a given accessible object is a constructor of {@link Record} class or
     * subclass.
     * <p>
     * Returns {@link InterceptionResult#INTERCEPT_REPLAYING} object if a given accessible object is a constructor of {@link Replay} class or
     * subclass.
     * <p>
     * Returns {@link InterceptionResult#NOT_OVERRIDEN} object otherwise.
     * 
     * @param accessibleObject Given accessible object
     * @return An {@link InterceptionResult} object
     */
    public InterceptionResult handleRecordAndReplayConstructors(AccessibleObject accessibleObject) {
        InterceptionResult result = InterceptionResult.NOT_OVERRIDEN;

        if (accessibleObject instanceof Constructor) {
            Constructor<?> constructor = (Constructor<?>) accessibleObject;
            Class<?> clazz = constructor.getDeclaringClass();

            if (Record.class.isAssignableFrom(clazz)) {
                result = InterceptionResult.INTERCEPT_RECORDING;
            } else if (Replay.class.isAssignableFrom(clazz)) {
                result = InterceptionResult.INTERCEPT_REPLAYING;
            }
        }

        return result;
    }
}
